Error Handling & Sistem Tahan Gagal
Error bukan pengecualian, ia keniscayaan. Query database gagal, API eksternal timeout, pengguna mengirim data buruk, dan logika bisnis kena kasus tepi yang tidak diprediksi siapa pun. Bab ini membangun pola pikir dan perlengkapan konkret untuk mendeteksi, menangani, dan memulihkan diri dari setiap kelas error backend, sebelum diam-diam menguras uang atau kepercayaan Anda.
Pola Pikir Tahan Gagal
"Pertanyaannya bukan apakah error akan terjadi, tapi bagaimana Anda menanganinya saat terjadi."
Setiap engineer backend harus menginternalisasi kebenaran sederhana: sistem Anda akan gagal. Bukan mungkin. Akan. Sumbernya ada di mana-mana:
- Query database kadang gagal atau timeout.
- API eksternal (pembayaran, email, auth) akan mati.
- Pengguna akan mengirim data salah bentuk, hilang, atau jahat.
- Logika bisnis akan kena kasus tepi yang tidak terpikirkan siapa pun saat desain.
Sistem tahan gagal bukan yang tidak pernah rusak. Ia yang rusak secara bisa diprediksi, pulih dengan anggun, dan memberi tahu Anda persis apa yang terjadi. Mencapainya butuh pergeseran pola pikir yang disengaja, dari "nanti kuhandle error" menjadi "kudesisain untuk kegagalan sejak hari pertama".
Lima Kelas Error Backend
Error backend bisa dikelompokkan ke lima kategori luas. Masing-masing punya asal, strategi deteksi, dan perbaikan berbeda.
Aplikasi berjalan tapi menghasilkan hasil yang salah. Paling sulit dideteksi. Bisa diam-diam menguras uang selama berminggu-minggu.
Kegagalan koneksi, deadlock, pelanggaran constraint, SQL salah bentuk. Bisa merobohkan seluruh aplikasi.
API pihak ketiga (pembayaran, email, auth) timeout, kena rate-limit, atau offline. Anda tidak punya kendali.
Pengguna mengirim data buruk, hilang, atau di luar rentang. Paling mudah ditangani, jika lapisan validasi Anda kokoh.
Env var hilang, kredensial salah saat deploy. Muncul saat startup, bukan runtime, jika Anda melakukannya dengan benar.
Logic Error: Pembunuh Senyap
Logic error adalah kelas paling berbahaya karena aplikasi Anda terus berjalan, ia hanya melakukan hal yang salah. Tanpa crash, tanpa stack trace, tanpa respons 500. Hanya hasil yang diam-diam salah yang menumpuk seiring waktu.
Contoh Klasik
Platform e-commerce menerapkan diskon dua kali karena bug di mesin promosi. Hasilnya: biaya pengiriman negatif. Aplikasi berjalan sempurna. Setiap order terkirim. Perusahaan rugi di setiap transaksi. Ini tak disadari berminggu-minggu karena tidak ada alert monitoring yang menyala pada "biaya pengiriman negatif".
Akar Penyebab yang Umum
- Kebutuhan yang disalahpahami: catatan dari rapat sprint ambigu; Anda mengimplementasikan apa yang Anda kira diminta, bukan yang dimaksud.
- Algoritma salah: formula diskon atau harga kompleks punya off-by-one atau operator salah (
*alih-alih+). - Kasus tepi tak tertangani: pengguna yang belum pernah membeli memicu jalur diskon "berbasis pembelian lampau" yang tidak dirancang untuk pengguna nol pembelian.
Strategi Pencegahan
- Tulis unit test untuk setiap aturan bisnis, terutama logika diskon, harga, dan izin.
- Tambahkan monitoring metrik bisnis (mis. alert jika rata-rata nilai order turun >20% dalam satu jam).
- Pakai property-based testing (Go:
gopter, Python:hypothesis) untuk membuat input kasus tepi secara otomatis. - Wajibkan peer review untuk semua perubahan kode terkait pembayaran dan auth.
Error Database
Kebanyakan aplikasi backend tak berarti tanpa database-nya. Error database jenis apa pun berarti aplikasi Anda tidak bisa menyajikan data nyata, yang biasanya berarti UI rusak atau kegagalan berantai lintas layanan.
(1) Error Koneksi
Backend Anda tidak bisa menjangkau server database. Penyebab yang mungkin:
- Partisi jaringan atau kegagalan DNS antara server aplikasi dan server DB.
- Server database kelebihan beban atau mati.
- Connection pool habis: semua koneksi TCP yang dipool sedang dipakai; request baru mengantre atau langsung gagal.
pgxpool (Go), psycopg2 pool or SQLAlchemy (Python), pg Pool (Node). Size your pool carefully: too small -> bottleneck; too large -> DB overload.(2) Error Pelanggaran Constraint
Anda mencoba melakukan operasi yang melanggar aturan tingkat database:
| Tipe Constraint | Pemicu | Respons yang Tepat |
|---|---|---|
| Unique | Sisipkan email / username duplikat | HTTP 409 Conflict atau 400, "Email sudah dipakai" |
| Foreign Key | Mereferensikan baris yang tidak ada | HTTP 404, "Author ID tidak ditemukan" / 400 |
| Not Null | Nilai kolom wajib hilang | HTTP 400, "Field X wajib diisi" |
| Check | Nilai gagal aturan kustom (mis. harga > 0) | HTTP 400, pesan spesifik domain |
(3) Error Query / Sintaks
SQL salah bentuk, typo nama tabel, mereferensikan kolom yang diganti nama, atau kondisi join yang hilang. Ini biasanya tertangkap di pengembangan tapi bisa lolos jika string SQL mentah dibangun secara dinamis.
(4) Deadlock
Deadlock terjadi saat dua (atau lebih) transaksi masing-masing memegang kunci yang dibutuhkan yang lain:
Postgres mendeteksi deadlock otomatis dan mematikan satu transaksi dengan kode error 40P01. Aplikasi Anda harus me-retry transaksi itu. Pencegahan: selalu ambil kunci dalam urutan yang konsisten di semua jalur kode.
Error Layanan Eksternal
Backend SaaS modern bergantung pada konstelasi layanan pihak ketiga, pemroses pembayaran (Stripe), email (Resend, SendGrid), object storage (S3), auth (Clerk, Auth0), AI (OpenAI). Setiap satu dari ini adalah titik kegagalan di luar kendali Anda.
(1) Kegagalan Jaringan
Internet antara server Anda dan API eksternal tidak andal. Anda akan menjumpai: connection timeout, kegagalan resolusi DNS, partisi jaringan, dan error handshake TLS. Set timeout eksplisit pada setiap panggilan HTTP keluar, jangan pernah biarkan API pihak ketiga yang lambat memblokir goroutine / thread Anda tanpa batas.
(2) Rate Limiting: HTTP 429
Setiap API eksternal serius menegakkan rate limit untuk mencegah penyalahgunaan. Jika aplikasi Anda menghantam API (karena bug, lonjakan lalu lintas, atau error loop), Anda akan menerima HTTP 429 Too Many Requests.
Mitigasi standarnya Exponential Backoff dengan Jitter:
(3) Gangguan Layanan / Downtime
Penyedia cloud besar (AWS, GCP) dan layanan SaaS populer kadang mati. Aplikasi Anda butuh strategi saat dependensi kritis sepenuhnya tidak tersedia:
- Fallback: jika cache Redis mati, jatuh ke pembacaan DB langsung untuk data non-kritis.
- Degradasi anggun: nonaktifkan fitur yang terdampak (mis. "saran AI untuk sementara tidak tersedia") alih-alih memecahkan seluruh aplikasi.
- Pola circuit breaker: setelah N kegagalan beruntun, berhenti mengirim request ke layanan yang rusak dan kembalikan respons cache/default seketika. Coba lagi layanannya setelah periode pendinginan.
Error Validasi Input
Ini error paling mudah ditangani karena Anda yang mendefinisikan aturannya. Lapisan validasi Anda adalah garis pertahanan pertama: tangkap data buruk di titik masuk, sebelum ia mencapai database atau logika bisnis Anda.
Jenis Validasi
| Tipe | Yang Diperiksa | Contoh |
|---|---|---|
| Format | Bentuk/pola nilai | Regex email, tanggal ISO, telepon E.164 |
| Rentang | Batas numerik, panjang string, ukuran larik | Harga: 0-99999, nama: 2-100 karakter |
| Wajib | Field wajib ada | user_id tidak boleh null |
| Aturan Bisnis | Batasan spesifik domain | enddate booking > startdate |
| Referensial | Entitas terkait benar-benar ada | category_id ada di tabel categories |
Selalu validasi di kedua lapisan: frontend (UX) dan backend (keamanan). Jangan pernah percaya validasi sisi klien sendirian. Backend adalah gerbang yang otoritatif.
400 Bad Request with a structured error body listing every field that failed and why. Don't return a single generic message, help the user fix all their mistakes in one round-trip.Error Konfigurasi
Error konfigurasi terjadi di batas antar lingkungan, dev -> staging -> produksi. OPENAIAPIKEY yang hilang, URL database yang salah, atau secret yang terlupakan bisa diam-diam memecah fitur spesifik sementara sisa aplikasi tampak sehat.
Gagal Cepat saat Startup: Bukan saat Runtime
Aturan emasnya: validasi semua environment variable wajib sebelum server mulai menerima lalu lintas. Jika ada yang hilang atau korup, crash seketika dengan pesan error yang jelas.
Buruk: Kegagalan Runtime
- Aplikasi mulai sukses
- Pengguna pertama memukul endpoint gambar AI
- Panggilan OpenAI gagal, key-nya hilang
- Pengguna dapat error 500 yang misterius
- Deployment lama sudah dihentikan
- Situs mati sampai diperbaiki manual
Baik: Kegagalan Startup
- Deployment baru mulai
- Validasi konfigurasi berjalan seketika
- Key hilang terdeteksi -> proses keluar dengan pesan jelas
- Blue-green: deployment lama masih berjalan
- Nol downtime, tim ops memperbaiki dan deploy ulang
Go: Validasi Konfigurasi saat Boot
Gopackage config import ( "fmt" "os" "strings" ) type Config struct { DatabaseURL string OpenAIKey string JWTSecret string ResendAPIKey string } // MustLoad panics if any required variable is missing. // Call this once in main() before http.ListenAndServe. func MustLoad() Config { required := []string{ "DATABASE_URL", "OPENAI_API_KEY", "JWT_SECRET", "RESEND_API_KEY", } var missing []string for _, key := range required { if os.Getenv(key) == "" { missing = append(missing, key) } } if len(missing) > 0 { // Crash immediately, loud and clear panic(fmt.Sprintf("[FATAL] missing required env vars: %s", strings.Join(missing, ", "))) } return Config{ DatabaseURL: os.Getenv("DATABASE_URL"), OpenAIKey: os.Getenv("OPENAI_API_KEY"), JWTSecret: os.Getenv("JWT_SECRET"), ResendAPIKey: os.Getenv("RESEND_API_KEY"), } } // main.go func main() { cfg := config.MustLoad() // panics here if config invalid server := newServer(cfg) log.Fatal(server.ListenAndServe()) }
Deteksi Error Proaktif: Health Check
"Penanganan error terbaik dimulai sebelum error terjadi."
Health check terus-menerus memverifikasi bahwa sistem Anda bekerja, bukan hanya bahwa ia berjalan. Ada perbedaan kritis:
Apa yang Diperiksa
- Database: jalankan query ringan yang representatif. Lacak waktu query; jika melonjak dari 50ms ke 4 detik, ada yang salah sebelum pengguna menyadarinya.
- Layanan eksternal: pemroses pembayaran: jalankan transaksi uji berkala; email: kirim ke alamat internal; auth: buat dan validasi token uji.
- Konfigurasi: verifikasi semua env var wajib dimuat dan tidak kosong saat startup.
- Pemanasan cache: pastikan cache kritis (session store, katalog produk) terisi sebelum melayani lalu lintas.
Go: Deep Health Check Endpoint
Gotype HealthStatus struct { Status string `json:"status"` Checks map[string]string `json:"checks"` } func healthHandler(db *pgxpool.Pool, rdb *redis.Client) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { checks := map[string]string{} overall := "ok" // DB check ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := db.Ping(ctx); err != nil { checks["database"] = "unhealthy: " + err.Error() overall = "degraded" } else { checks["database"] = "ok" } // Redis check if err := rdb.Ping(r.Context()).Err(); err != nil { checks["cache"] = "unhealthy: " + err.Error() overall = "degraded" } else { checks["cache"] = "ok" } status := http.StatusOK if overall != "ok" { status = http.StatusServiceUnavailable } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(HealthStatus{Status: overall, Checks: checks}) } }
Monitoring & Observabilitas
Health check memberi tahu Anda ada yang rusak sekarang. Monitoring memberi tahu Anda ada yang hampir rusak, dan memberi konteks untuk memahami kenapa sesuatu rusak setelah kejadian.
Yang Dilacak
| Kategori | Metrik untuk Dipantau | Kenapa |
|---|---|---|
| Lapisan HTTP | Tingkat 4xx / 5xx, latensi p50/p95/p99 | Memunculkan masalah yang terlihat pengguna secara langsung |
| Database | Durasi query, pemakaian connection pool, jumlah deadlock | Mendeteksi query lambat sebelum timeout |
| Layanan eksternal | Tingkat sukses panggilan, latensi, jumlah 429 | Tahu kapan dependensi sedang menurun |
| Metrik bisnis | Transaksi sukses/menit, pembayaran gagal, tingkat pendaftaran | Menangkap logic error yang tak terlihat dari tingkat error |
| Infrastruktur | CPU, memori, I/O disk, throughput jaringan | Kehabisan resource mendahului crash |
Structured Logging (JSON)
Log teks polos sulit di-query di skala. Pakai log JSON terstruktur supaya tool agregasi log (Grafana Loki, Datadog, ELK) bisa mem-parse, memfilter, dan memberi alert padanya secara programatik.
Python / structlogimport structlog log = structlog.get_logger() # Good, structured, queryable, no sensitive data log.error( "payment_failed", user_id="u_9a3f", # ID, not email correlation_id="req_abc123", provider="stripe", error_code="card_declined", amount_cents=4999, ) # BAD, never log PII or secrets # log.error("payment_failed", email="alice@example.com", card="4242...")
Strategi Pemulihan
Dapat Dipulihkan vs Tidak
Error yang Dapat Dipulihkan
- Gangguan jaringan sesaat ke API email
- Connection pool database untuk sementara habis
- Rate limit 429 dari layanan eksternal
Strategi: Retry dengan exponential backoff. Antrekan pekerjaannya. Jangan menyerah seketika.
Error yang Tidak Dapat Dipulihkan
- Klaster Redis sepenuhnya mati
- Pemroses pembayaran offline berjam-jam
- Data korup di DB
Strategi: Degradasi anggun. Fallback. Nonaktifkan fiturnya. Lindungi fungsionalitas inti.
Exponential Backoff di Go
Gofunc sendEmailWithRetry(to, subject, body string) error { maxRetries := 5 baseDelay := 1 * time.Second for attempt := 0; attempt < maxRetries; attempt++ { err := emailClient.Send(to, subject, body) if err == nil { return nil // success } if !isRetryable(err) { return fmt.Errorf("permanent failure: %w", err) } // Exponential backoff: 1s, 2s, 4s, 8s, 16s wait := baseDelay * time.Duration(1<<attempt) // Add jitter (+/-20%) to prevent thundering herd jitter := time.Duration(rand.Int63n(int64(wait / 5))) time.Sleep(wait + jitter) log.Warn("email send failed, retrying", "attempt", attempt+1, "wait_ms", wait.Milliseconds(), "error", err) } return fmt.Errorf("all %d retries exhausted", maxRetries) } func isRetryable(err error) bool { // Retry on 429, 503, network errors; not on 400, 401, 422 var httpErr *HTTPError if errors.As(err, &httpErr) { return httpErr.StatusCode == 429 || httpErr.StatusCode >= 500 } return true // network errors are always retryable }
Pemulihan Otomatis vs Manual
- Otomatis: restart proses yang crash (systemd, kebijakan restart Kubernetes), bersihkan cache korup, pindah ke sistem cadangan. Rancang hati-hati, pemulihan otomatis kadang bisa memperbesar masalah.
- Manual: korupsi data, selisih pembayaran, insiden keamanan. Ini butuh penilaian manusia. Dokumentasikan runbook-nya. Ujilah. Ketahui siapa yang on-call.
Global Error Handler: Jaring Pengaman Terakhir
Global error handler adalah satu middleware yang duduk di lapisan terluar aplikasi Anda, menangkap setiap error yang memuncak dari lapisan mana pun, dan mengubahnya menjadi respons HTTP yang diformat dengan benar.
Dua Keuntungan Utama
- Tidak ada kondisi error yang terlupakan: setiap error tak tertangani jatuh ke kasus default handler global (
500 + "something went wrong"). Tidak ada yang ditelan diam-diam. - Nol redundansi: logika penanganan error database tinggal di satu file, bukan tersebar di 40 method repository. Ubah pesan pelanggaran-unique sekali, ia berlaku di mana-mana.
Go: Implementasi Global Error Handler
Go tidak punya exception, error adalah nilai kembalian. Polanya mengembalikan error ke atas call stack dan menanganinya di middleware.
Go / errors/types.gopackage apperr import "net/http" // AppError is the canonical error type for this application. type AppError struct { Code int // HTTP status code Message string // Safe, user-facing message Details any // Optional: field-level errors for 400s Err error // Original error, for logging only, NEVER sent to client } func (e *AppError) Error() string { return e.Message } // Constructors func NotFound(resource string) *AppError { return &AppError{Code: http.StatusNotFound, Message: resource + " not found"} } func Conflict(msg string) *AppError { return &AppError{Code: http.StatusConflict, Message: msg} } func BadRequest(msg string, details any) *AppError { return &AppError{Code: http.StatusBadRequest, Message: msg, Details: details} } func Internal(err error) *AppError { return &AppError{ Code: http.StatusInternalServerError, Message: "something went wrong", // NEVER expose err.Error() here Err: err, } }
Go / middleware/error_handler.gopackage middleware import ( "encoding/json" "errors" "log/slog" "net/http" "github.com/jackc/pgx/v5/pgconn" apperr "yourapp/errors" ) type ErrorResponse struct { Code int `json:"code"` Message string `json:"message"` Details any `json:"details,omitempty"` } // GlobalErrorHandler wraps a handler that returns an error. func GlobalErrorHandler(next func(http.ResponseWriter, *http.Request) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { err := next(w, r) if err == nil { return } var appErr *apperr.AppError switch { // Already wrapped as AppError case errors.As(err, &appErr): if appErr.Err != nil { slog.Error("app error", "err", appErr.Err) } // Postgres unique constraint violation -> 409 case isPgError(err, "23505"): appErr = apperr.Conflict("resource already exists") // Postgres foreign key violation -> 404 case isPgError(err, "23503"): appErr = apperr.NotFound("referenced resource") // pgx no-rows -> 404 case errors.Is(err, pgx.ErrNoRows): appErr = apperr.NotFound("resource") // Everything else -> 500 (never leak internal error) default: slog.Error("unhandled error", "err", err) appErr = apperr.Internal(err) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(appErr.Code) json.NewEncoder(w).Encode(ErrorResponse{ Code: appErr.Code, Message: appErr.Message, Details: appErr.Details, }) } } func isPgError(err error, code string) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == code }
Python: Global Error Handler (FastAPI)
Python / FastAPIfrom fastapi import FastAPI, Request from fastapi.responses import JSONResponse from psycopg2 import errors as pg_errors import logging app = FastAPI() logger = logging.getLogger("app") # --- Custom exception types --- class AppError(Exception): def __init__(self, status: int, message: str, details=None): self.status = status self.message = message self.details = details class NotFoundError(AppError): def __init__(self, resource: str): super().__init__(404, f"{resource} not found") class ConflictError(AppError): def __init__(self, msg: str): super().__init__(409, msg) # --- Global exception handlers --- @app.exception_handler(AppError) async def app_error_handler(request: Request, exc: AppError): return JSONResponse( status_code=exc.status, content={"code": exc.status, "message": exc.message, "details": exc.details} ) @app.exception_handler(pg_errors.UniqueViolation) async def unique_violation_handler(request: Request, exc): logger.warning("unique_violation", extra={"path": request.url.path}) return JSONResponse(status_code=409, content={"code": 409, "message": "resource already exists"}) @app.exception_handler(pg_errors.ForeignKeyViolation) async def fk_violation_handler(request: Request, exc): return JSONResponse(status_code=404, content={"code": 404, "message": "referenced resource not found"}) @app.exception_handler(Exception) async def unhandled_error_handler(request: Request, exc: Exception): # Log the real error internally, never expose it logger.error("unhandled_exception", exc_info=exc, extra={"path": request.url.path}) return JSONResponse(status_code=500, content={"code": 500, "message": "something went wrong"})
Keamanan: Yang Diekspos, Yang Disembunyikan
(1) Jangan Pernah Membocorkan Detail Internal
Pesan error database dari Postgres memuat nama tabel, nama kolom, nama index, dan nama constraint. Jika Anda meneruskan pesan pgconn.PgError mentah langsung ke klien, penyerang mempelajari skema Anda dan bisa menyusun percobaan SQL injection yang lebih tertarget.
| Yang Anda Dapat | Yang Dikirim ke Klien |
|---|---|
duplicate key value violates unique constraint "usersemailkey" | "Email sudah dipakai" |
relation "usres" does not exist (typo) | "Ada yang salah" |
deadlock detected on relation 42816 | "Ada yang salah, coba lagi" |
stack trace: panic at server.go:142 | "Internal server error" |
(2) Error Auth yang Samar (Disengaja)
Endpoint login adalah permukaan yang paling banyak diserang di aplikasi mana pun. Jika Anda mengembalikan pesan spesifik seperti "tidak ada user dengan email ini" vs "kata sandi salah", penyerang bisa menghitung email valid lewat loop sederhana.
(3) Praktik Logging yang Aman
Log sering dikirim ke layanan agregasi pihak ketiga (Datadog, Grafana Cloud, ELK). Dalam pelanggaran data besar, file log yang bocor mengekspos jutaan record, karena engineer ceroboh mencatat field sensitif.
- Jangan pernah log: kata sandi, API key, nomor kartu kredit, SSN, alamat email lengkap, session token.
- Log sebagai gantinya: user ID (bukan email), correlation/request ID, nama operasi, kode error.
- Pakai library pembersih log (mis. Go:
slogdengan handler kustom; Python: prosesorstructlog) untuk meng-redact otomatis field sensitif yang dikenal.
Go / safe vs unsafe logging//UNSAFE, never do this slog.Error("login_failed", "email", user.Email, // PII leak "password", req.Password, // catastrophic "api_key", cfg.OpenAIKey, // secret leak ) // SAFE, IDs and correlation only slog.Error("login_failed", "user_id", user.ID, "correlation_id", r.Header.Get("X-Request-ID"), "reason", "invalid_credentials", // generic code, not DB message )
Referensi & Bacaan Lanjutan
Backend Field Manual / Error Handling & Fault Tolerance / Bab 16